S00-00 General-Cloudflare-项目:jav-fs
[TOC]
项目开发文档大纲
这是一个非常棒的计划。我们将项目命名为 jav-fs (JAV Full Stack)。
基于我们之前的深入讨论,我已经为你整理了一份系统性的开发文档大纲。为了让你能按部就班地完成开发,我将整个流程拆分为 6 个独立的里程碑(Steps)。
你可以把这份大纲当作我们的总控文档。接下来,我们可以按照顺序,每次只专注完成一个步骤。
技术栈
- 目标:构建一个高性能、低成本的 AV 作品与女优管理系统。
- 核心技术栈:
- Runtime: Cloudflare Pages Functions (Serverless)
- Framework: Hono (Backend) + Vue 3 (Frontend)
- Database: Cloudflare D1 (SQLite)
- Storage: Cloudflare R2 (Object Storage)
- Language: TypeScript (全栈类型安全)
开发路线图
我们将按照以下 6 个步骤进行开发。每完成一步,你都可以向我发送指令(例如:“开始第一步”),我会生成该步骤详细的代码和操作指南。
Step 1: 项目初始化与基础设施搭建:
目标:搭建 Monorepo 结构,配置 Cloudflare 环境。
- 创建 Vite + Vue TS 项目。
- 安装 Hono 及相关依赖。
- 配置
wrangler.toml(绑定 D1 和 R2)。 - 建立目录结构 (
functions/vssrc/)。 - 产出:一个可以跑通 "Hello World" 的全栈空壳项目。
Step 2: 数据库架构设计:
目标:设计并应用数据库模型,解决 JSON 与 关联表的性能平衡。
- 编写
schema.sql。 - 实现
idols表(含虚拟列优化bwh搜索)。 - 实现
works表与work_idols多对多关联表。 - 执行 D1 迁移命令。
- 产出:完善的数据库结构,准备好录入数据。
- 编写
Step 3: 后端模块化开发:
目标:使用 Hono 的路由分组功能实现可维护的后端 API。
- 定义全栈共享的 TypeScript 类型 (
Shared Types)。 - 实现模块化路由:
functions/routes/idols.ts和works.ts。 - 实现核心查询逻辑(包括关联查询优化)。
- 产出:功能完善的 REST API,可进行 CRUD 操作。
- 定义全栈共享的 TypeScript 类型 (
Step 4: 对象存储与图片服务:
目标:实现图片的上传、存储与高性能读取。
- 开发
upload.ts路由,处理 Multipart 上传。 - 开发图片代理/流式传输接口。
- 处理 MIME 类型和 HTTP 缓存头。
- 产出:图片上传接口,支持前端显示图片。
- 开发
Step 5: 前端 Vue 架构与页面开发:
目标:对接后端 API,构建用户界面。
- 配置 Vite Proxy 解决本地开发跨域问题。
- 封装
useApi或fetch请求库。 - 开发“女优列表页” (Grid 布局 + 分页)。
- 开发“上传/新建页”。
- 产出:完整的前端交互界面。
Step 6: 安全、优化与部署:
目标:生产环境准备与上线。
- 配置 CORS 策略。
- 添加 API 鉴权中间件 (Bearer Token)。
- 前端构建与 Wrangler 部署流程。
- 产出:项目正式上线,可被公网访问。
项目初始化与基础设施搭建
目标:搭建 Monorepo 结构,配置 Cloudflare 环境。
- 创建 Vite + Vue TS 项目。
- 安装 Hono 及相关依赖。
- 配置
wrangler.toml(绑定 D1 和 R2)。 - 建立目录结构 (
functions/vssrc/)。 - 产出:一个可以跑通 "Hello World" 的全栈空壳项目。
创建项目
创建 Monorepo 结构的项目:
我们将使用 Vite 来创建项目,它将作为我们前端构建工具,同时也是我们项目的根目录。
打开终端,执行以下命令:
# 1. 创建 Vue + TypeScript 项目
npm create vite@latest jav-fs -- --template vue-ts
# pnpm 写法(可以省略 `--`,因为 pnpm 会只能区分哪些参数属于它,哪些属于 vite ):
pnpm create vite@latest jav-fs --template vue-ts
# 2. 进入项目目录
cd jav-fs
# 3. 安装前端依赖
pnpm install
# 4. 安装后端核心依赖 (Hono)
pnpm install hono
# 5. 安装 Cloudflare 开发依赖 (Wrangler & Types)
pnpm install -D wrangler @cloudflare/workers-types硬链接开发文档
为创建的开发文档创建一个硬链接,映射到 note 笔记项目中
# windows
mklink /H "D:\Code\Note\doc\general\S00-00 General-Cloudflare-项目:jav-fs.md" "D:\Code\Project\Jav\jav-fs\项目:jav-fs.md"- 注意:
- 该命令为 windows 命令,mac 系统命令为:
ln <原文件名> <硬链接文件名>。 - 文件名部分需要使用
"<文件名>"包裹,因为文件名中有空格存在。
- 该命令为 windows 命令,mac 系统命令为:
配置 wrangler.jsonc
配置 Cloudflare 环境 (:wrangler.jsonc)
在项目根目录(jav-fs/)下创建一个名为 wrangler.jsonc 的文件。这是 Cloudflare Pages 的配置文件,用于定义数据库和存储桶的绑定。
注意:此时你还不需要在 Cloudflare 后台真的创建数据库,我们先配置好本地开发环境。
// jav-fs/wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "jav-fs",
"compatibility_date": "2026-02-13",
"pages_build_output_dir": "./dist",
"compatibility_flags": ["nodejs_compat"],
"r2_buckets": [
{
"binding": "R2_JAV",
"bucket_name": "jav"
}
],
"d1_databases": [
{
"binding": "DB_JAV",
"database_name": "jav",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // 部署时才需要真实 ID
},
{
"binding": "DB_STORAGE",
"database_name": "storage",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // 部署时才需要真实 ID
}
]
}# jav-fs/wrangler.toml
name = "jav-fs"
pages_build_output_dir = "./dist"
compatibility_date = "2024-02-12"
# 绑定 D1 数据库 (本地开发时会自动创建 SQLite 文件)
[[d1_databases]]
binding = "DB"
database_name = "jav-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # 部署时才需要真实 ID
# 绑定 R2 存储桶
[[r2_buckets]]
binding = "R2"
bucket_name = "jav-assets"规划目录结构
规划目录结构:
我们需要在根目录下创建一个 functions 文件夹。这是 Cloudflare Pages 的约定:任何放在 functions 文件夹下的代码都会被自动部署为后端 API。
在终端执行:
mkdir -p functions/api此时你的目录结构应该是这样的:
jav-fs/
├── functions/ <-- 后端代码在这里
│ └── api/
├── src/ <-- 前端 Vue 代码在这里
├── public/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
└── wrangler.toml <-- 配置文件创建第一个 Hono 后端接口
创建第一个 Hono 后端接口:
我们先写一个 "Hello World" 接口来测试环境是否打通。
创建文件:functions/api/[[route]].ts
[[route]]是 Cloudflare 的通配符语法,表示捕获/api/*下的所有请求。typescript// functions/api/[[route]].ts import { Hono } from 'hono' import { handle } from 'hono/cloudflare-pages' // 定义环境类型 type Bindings = { DB: D1Database R2: R2Bucket } // 初始化 Hono,设置基础路径为 /api const app = new Hono<{ Bindings: Bindings }>().basePath('/api') // 测试路由 app.get('/hello', (c) => { return c.json({ message: 'Hello from Hono & Cloudflare!', timestamp: new Date().toISOString() }) }) // 导出处理函数 export const onRequest = handle(app)
配置前后端代理
配置前后端联调 (Vite Proxy):
在本地开发时,Vue 运行在 http://localhost:5173,而 Cloudflare Wrangler (后端) 运行在 http://127.0.0.1:8788。
为了让前端能请求到后端,我们需要配置 Vite 的代理。
修改 vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
// 当前端请求 /api/xxx 时,自动转发到 Wrangler 的端口 8788
'/api': {
target: 'http://127.0.0.1:8788',
changeOrigin: true
}
}
}
})编写启动脚本
编写启动脚本 (:package.json)
为了方便开发,我们在 package.json 中添加一个脚本,同时启动后端模拟环境。
修改 package.json 的 scripts 部分:
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"backend": "wrangler pages dev --local --port 8787"
}验证时刻
现在,我们来验证整个环境是否搭建成功。你需要打开两个终端窗口。
终端 1 (启动后端):
pnpm run backend- 你应该会看到 Wrangler 启动,并提示监听在
http://127.0.0.1:8788。 - 它会自动在本地
.wrangler/目录下模拟 D1 和 R2,不需要联网。
终端 2 (启动前端):
pnpm run dev- Vite 会启动在
http://localhost:5173。
测试联通性:
打开浏览器访问:http://localhost:5173/api/hello
如果你看到如下 JSON 返回,恭喜你!Step 1 完美完成!🎉
{
"message": "Hello from Hono & Cloudflare!",
"timestamp": "202X-XX-XXT..."
}遇到问题了
报错
Command not found: wrangler:确保你运行的是
npm run backend或者前面加了npx。浏览器显示 404:
检查 URL 是否是
/api/hello。确保两个终端都在运行。Hono 严格路由模式区分
/api/hello和/api/hello/先设置
basePath('/api'),然后定义app.get('/hello/')。这意味着 Hono 内部注册的完整路由是
/api(即/api+/hello/,但尾部斜杠被规范化掉了)。关键点在于:Hono 默认是严格匹配路由的,
/api/hello和/api/hello/被视为两个不同的路径:请求 URL Hono 匹配的路径 结果 /api/hello/api/hello→ 匹配get('/hello')✅ 200 /api/hello//api/hello/→ 没有匹配的路由❌ 404 解决方案:
在创建 Hono 实例时,将
strict选项设为false,这样 Hono 会将/api/hello和/api/hello/视为相同路径:diff-const app = new Hono<{ Bindings: Bindings }>().basePath('/api') +const app = new Hono<{ Bindings: Bindings }>({ strict: false }).basePath('/api')strict: false:会让 Hono 在路由匹配时忽略尾部斜杠的差异,这样/api/hello和/api/hello/都能正确匹配到get('/hello/')路由。
数据库架构设计
目标:设计并应用数据库模型,解决 JSON 与 关联表的性能平衡。
- 编写
schema.sql。 - 实现
idols表(含虚拟列优化bwh搜索)。 - 实现
works表与work_idols多对多关联表。 - 执行 D1 迁移命令。
- 建立 TypeScript 类型定义
- 产出:完善的数据库结构,准备好录入数据。
我们将实现 “读写分离” 的策略:
写(关联表):使用
work_idols处理多对多关系,确保数据严谨,方便搜索。读(快照):在
works表中冗余存储idols_snapshot(JSON),确保列表页加载飞快,无需 JOIN。
编写 schema.sql
在项目根目录下创建一个名为 schema.sql 的文件。
我们将应用之前讨论的 生成列 (Generated Columns) 技术来优化 JSON 字段的索引(例如女优的罩杯/胸围)。
-- 1. AV 女优表: idols
CREATE TABLE IF NOT EXISTS idols (
-- 核心字段
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
aliases TEXT, -- JSON Object: {"jp": "みやした れな", "en": "Miyashita Rena", "more": "['别名1','别名2']"}
avatar_url TEXT,
-- 身体数据
cup TEXT,
height INTEGER, -- 单位: cm
birthday TEXT, -- YYYY-MM-DD
hobbies TEXT, -- JSON Array: ["爱好1", "爱好2"]
desc TEXT, -- 简介
social_links TEXT, -- JSON Array:[{"type": "twitter", "url": "https://twitter.com/xxx"}]
bwh TEXT, -- JSON Object: {"bust": 90, "waist": 60, "hips": 90}
-- [优化] 虚拟列
bust_size INTEGER GENERATED ALWAYS AS (json_extract(bwh, '$.bust')) VIRTUAL,
-- 作品数据
work_codes TEXT, -- JSON Array:["ABP-123", "IPZZ-456"]
-- 状态数据
is_banned INTEGER DEFAULT 0, -- 0: false, 1: true
is_favorite INTEGER DEFAULT 0, -- 0: false, 1: true
status INTEGER DEFAULT 1, -- 当前状态(0:引退,1:活跃,2:休业)
debut_date TEXT, -- 出道日期(YYYY-MM-DD)
retirement_date TEXT, -- 引退日期(YYYY-MM-DD)
-- 关联数据
agencies TEXT, -- JSON Array: ["经纪公司1", "经纪公司2"]
makers TEXT, -- JSON Array: ["厂商1", "厂商2"]
-- 时间戳
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
-- 索引设计
CREATE UNIQUE INDEX IF NOT EXISTS idx_idols_name_unique ON idols(name);
CREATE INDEX IF NOT EXISTS idx_idols_cup ON idols(cup);
CREATE INDEX IF NOT EXISTS idx_idols_bust_size ON idols(bust_size);
-- 建议给出道日期加索引,方便查询“最新出道”
CREATE INDEX IF NOT EXISTS idx_idols_debut_date ON idols(debut_date);-- 2. AV 作品表: works
CREATE TABLE IF NOT EXISTS works (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, -- 番号,如:ABP-123
code_prefix TEXT NOT NULL, -- 番号前缀,如:ABP
full_title TEXT, -- 包含系列、标题、是否无码、是否有字幕等信息的完整标题
-- 关联数据
idols_snapshot TEXT, -- JSON Array: [{id, name}] (冗余存储用于快速显示)
tags_snapshot TEXT, -- JSON Array: [{id, name}] (冗余存储)
-- 标记位
is_u INTEGER DEFAULT 0, -- 无码
is_c INTEGER DEFAULT 0, -- 字幕
is_ad INTEGER DEFAULT 0, -- 广告
is_4k INTEGER DEFAULT 0, -- 4K
-- 网盘视频数据
one_file_id TEXT, -- 用于移动、改名、删除
one_pick_code TEXT, -- 用于下载、看视频
one_sha1 TEXT, -- 用于秒传、去重
file_size TEXT, -- 单位:Byte
duration INTEGER, -- 单位: 秒
-- 图片数据
img_preview_grid_url TEXT, -- 预览网格图
img_cover_url TEXT, -- 封面图
img_sample_urls TEXT, -- JSON Array:["gif1_url", "gif2_url"]
-- 元数据
release_date TEXT, -- YYYY-MM-DD
series TEXT, -- 系列,如:初中出し解禁
director TEXT, -- 导演
maker TEXT, -- 厂商
-- 自定义数据
rating INTEGER DEFAULT 0, -- 作品评分:0-10,整数
bookmarks TEXT, -- 视频书签,JSON Array:[{"time": 90, "tip": "书签信息", "img": "书签图片链接"}]
-- 其他字段
title TEXT NOT NULL,
desc TEXT, -- 简介(手动添加)
-- 时间戳
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
-- 索引设计
CREATE INDEX IF NOT EXISTS idx_works_code ON works(code);
CREATE INDEX IF NOT EXISTS idx_works_code_prefix ON works(code_prefix);
CREATE INDEX IF NOT EXISTS idx_works_release_date ON works(release_date);
CREATE INDEX IF NOT EXISTS idx_works_maker ON works(maker);
CREATE INDEX IF NOT EXISTS idx_works_is_u ON works(is_u);
CREATE INDEX IF NOT EXISTS idx_works_rating ON works(rating);-- 3. 标签表: tags
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
one_tag_id TEXT,
-- 其他字段
name_jp TEXT,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);-- 4. 关联表: work_idols (多对多)
CREATE TABLE IF NOT EXISTS work_idols (
work_id INTEGER,
idol_id INTEGER,
PRIMARY KEY (work_id, idol_id),
FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (idol_id) REFERENCES idols(id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- 索引设计:优化反向查询(Idol -> Works)
CREATE INDEX IF NOT EXISTS idx_work_idols_reverse ON work_idols(idol_id, work_id);-- 5. 关联表: work_tags (多对多)
CREATE TABLE IF NOT EXISTS work_tags (
work_id INTEGER,
tag_id INTEGER,
PRIMARY KEY (work_id, tag_id),
FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- 索引设计:优化反向查询(Tag -> Works)
CREATE INDEX IF NOT EXISTS idx_work_tags_reverse ON work_tags(tag_id, work_id);创建本地数据库
Cloudflare D1 允许我们在本地完全模拟数据库环境。我们需要执行命令让上面的 SQL 生效。
打开终端,运行:
# --local 表示只在本地 `.wrangler/` 目录下创建数据库文件,不影响线上
pnpm exec wrangler d1 execute jav --local --file=./schema.sql预期输出:
你会看到一系列 ✅ Executed x commands,表示表结构创建成功。
⚠️ 注意:如果你看到报错
Database not found,请检查wrangler.toml里[[d1_databases]]的database_name = "jav"是否配置正确。
问题:SQLite 不支持 ON UPDATE 列定义语法

解决方案:
删除
ON UPDATE
为每个表添加触发器来自动更新
updated_atsql-- 例如为 idols 表: CREATE TRIGGER IF NOT EXISTS trg_idols_updated_at AFTER UPDATE ON idols FOR EACH ROW BEGIN UPDATE idols SET updated_at = unixepoch() WHERE id = OLD.id; END;
定义 TypeScript 类型
定义 TypeScript 类型 (:functions/types.ts)
为了让后续写代码时有智能提示,我们需要把数据库结构翻译成 TS 接口。
在 functions/ 目录下创建一个 types.ts 文件(如果之前没有 functions 目录,请手动创建)。
// functions/types.ts
import { Hono } from 'hono'
// 1. 定义 D1 数据库绑定和 R2 存储桶
export type Bindings = {
DB: D1Database
R2: R2Bucket
API_SECRET?: string // 用于鉴权
}
// 2. 通用的 Hono 应用类型
export type HonoApp = Hono<{ Bindings: Bindings }>
// 3. 数据库模型 (对应 SQL 表结构)
// 女优模型
export interface Idol {
id: number
name: string
aliases: string[] // 存入 DB 时需 JSON.stringify
avatar_url?: string
cup?: string
bwh?: {
bust: number
waist: number
hips: number
}
bust_size?: number // 虚拟列
height?: number
birthday?: string
hobbies?: string
social_links?: string[]
isFavorite: number // SQLite 没有 boolean,用 0/1
created_at: number
}
// 作品模型
export interface Work {
id: number
code: string
title: string
// 快照字段
idols_snapshot: { id: number; name: string; avatar?: string }[]
tags_snapshot: string[]
release_date?: string
duration?: number
isU: number
isH: number
isC: number
img_cover_url?: string
maker?: string
}验证时刻
我们需要确认数据库真的建好了。我们可以写一个临时的测试脚本,或者直接用 Wrangler 的交互模式来查表。
在终端输入以下命令进入 D1 交互模式:
pnpm exec wrangler d1 execute jav --local --command "SELECT name FROM sqlite_master WHERE type='table';"预期输出:
你应该能看到以下表名列表:
┌────────────┐
│ name │
├────────────┤
│ idols │
│ works │
│ work_idols │
│ d1_migrations ... │
└────────────┘看到这些表名,说明我们的 Step 2: 数据库架构设计 已经圆满完成!你的本地环境现在已经拥有了一个专业的 AV 数据库结构。
后端模块化开发
目标:使用 Hono 的路由分组功能实现可维护的后端 API。
- 定义全栈共享的 TypeScript 类型 (
Shared Types)。 - 实现模块化路由:
functions/routes/idols.ts和works.ts。 - 实现核心查询逻辑(包括关联查询优化)。
- 产出:功能完善的 REST API,可进行 CRUD 操作。
路由目录结构
我们需要在 functions 目录下创建一个 routes 文件夹,用于存放不同业务模块的代码。
在终端执行:
mkdir -p functions/routes现在的目录结构:
functions/
├── api/
│ └── [[route]].ts <-- 主入口
├── routes/ <-- [新建] 业务逻辑
│ ├── idols.ts <-- [新建] 女优模块
│ └── works.ts <-- [新建] 作品模块
└── types.ts <-- [已存在] 类型定义idols 模块
编写女优模块 (:functions/routes/idols.ts)
这是最复杂的模块,因为它涉及到 JSON 字段的序列化(存)和反序列化(取)。
创建 functions/routes/idols.ts,写入以下代码:
import { Hono } from 'hono'
import { Bindings } from '../types'
const app = new Hono<{ Bindings: Bindings }>()
// 工具函数:安全解析 JSON
const safeParse = (str: string | null, fallback: any = []) => {
try {
return str ? JSON.parse(str) : fallback
} catch {
return fallback
}
}
// 1. 获取女优列表 (支持分页 & 搜索)
// GET /api/idols?page=1&search=Yua
app.get('/', async (c) => {
const page = Number(c.req.query('page') || 1)
const search = c.req.query('search')
const limit = 20
const offset = (page - 1) * limit
let query = 'SELECT * FROM idols'
const params: any[] = []
// 搜索逻辑
if (search) {
query += ' WHERE name LIKE ? OR aliases LIKE ?' //【此处 aliases 有问题】
params.push(`%${search}%`, `%${search}%`)
}
query += ' ORDER BY isFavorite DESC, id DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
const { results } = await c.env.DB.prepare(query)
.bind(...params)
.all()
// 数据处理:将 SQLite 的 TEXT/INT 转为前端好用的格式
const data = results.map((row: any) => ({
...row,
aliases: safeParse(row.aliases),
bwh: safeParse(row.bwh, {}),
social_links: safeParse(row.social_links),
hobbies: safeParse(row.hobbies),
// SQLite 存的是 0/1,转为 Boolean
isFavorite: Boolean(row.isFavorite),
// 计算年龄 (可选)
age: row.birthday ? Math.floor((new Date().getTime() - new Date(row.birthday).getTime()) / 31557600000) : null
}))
return c.json({
page,
data,
hasMore: data.length === limit
})
})
// 2. 获取单个女优详情
// GET /api/idols/:id
app.get('/:id', async (c) => {
const id = c.req.param('id')
const idol = await c.env.DB.prepare('SELECT * FROM idols WHERE id = ?').bind(id).first()
if (!idol) return c.json({ error: 'Not Found' }, 404)
return c.json({
...idol,
aliases: safeParse(idol.aliases as string),
bwh: safeParse(idol.bwh as string, {}),
social_links: safeParse(idol.social_links as string),
isFavorite: Boolean(idol.isFavorite)
})
})
// 3. 创建女优 (POST)
// POST /api/idols
app.post('/', async (c) => {
const body = await c.req.json()
// 简单校验
if (!body.name) return c.json({ error: 'Name is required' }, 400)
// 插入数据 (注意:数组/对象需要 stringify)
const result = await c.env.DB.prepare(
`
INSERT INTO idols (name, aliases, cup, bwh, birthday, avatar_url, social_links, desc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
)
.bind(
body.name,
JSON.stringify(body.aliases || []),
body.cup || null,
JSON.stringify(body.bwh || {}), // 例如 {bust: 90, waist: 60, hips: 90}
body.birthday || null,
body.avatar_url || null,
JSON.stringify(body.social_links || []),
body.desc || null
)
.run()
return c.json({ success: true, id: result.meta.last_row_id })
})
// 4. 切换收藏状态 (PATCH)
// PATCH /api/idols/:id/favorite
app.patch('/:id/favorite', async (c) => {
const id = c.req.param('id')
const { isFavorite } = await c.req.json()
await c.env.DB.prepare('UPDATE idols SET isFavorite = ? WHERE id = ?')
.bind(isFavorite ? 1 : 0, id)
.run()
return c.json({ success: true })
})
export default appworks 模块
编写作品模块 (:functions/routes/works.ts)
作品模块相对简单,主要是列表查询。
创建 functions/routes/works.ts:
import { Hono } from 'hono'
import { Bindings } from '../types'
const app = new Hono<{ Bindings: Bindings }>()
const safeParse = (str: string | null, fallback: any = []) => {
try {
return str ? JSON.parse(str) : fallback
} catch {
return fallback
}
}
// GET /api/works
app.get('/', async (c) => {
const page = Number(c.req.query('page') || 1)
const limit = 20
const offset = (page - 1) * limit
// 直接查询 works 表,不需要 JOIN,因为我们有 snapshots
const { results } = await c.env.DB.prepare(
`
SELECT * FROM works
ORDER BY release_date DESC
LIMIT ? OFFSET ?
`
)
.bind(limit, offset)
.all()
const data = results.map((row: any) => ({
...row,
idols: safeParse(row.idols_snapshot), // 直接拿快照显示头像和名字
tags: safeParse(row.tags_snapshot),
isU: Boolean(row.isU),
isH: Boolean(row.isH),
isC: Boolean(row.isC)
}))
return c.json({ data, page })
})
// GET /api/works/:code (根据番号查询)
app.get('/:code', async (c) => {
const code = c.req.param('code')
const work = await c.env.DB.prepare('SELECT * FROM works WHERE code = ?').bind(code).first()
if (!work) return c.json({ error: 'Work not found' }, 404)
return c.json({
...work,
idols: safeParse(work.idols_snapshot as string),
tags: safeParse(work.tags_snapshot as string),
isU: Boolean(work.isU),
isH: Boolean(work.isH),
isC: Boolean(work.isC)
})
})
export default app[[route]] 入口
组装主入口 (:functions/api/[[route]].ts)
最后,我们需要修改主入口文件,把上面写好的两个模块挂载上去。
修改 functions/api/[[route]].ts:
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'
import { cors } from 'hono/cors' // 记得安装依赖: npm install hono
import { Bindings } from '../types'
// 1. 引入子路由
import idolsApp from '../routes/idols'
import worksApp from '../routes/works'
const app = new Hono<{ Bindings: Bindings }>().basePath('/api')
// 全局中间件
app.use('/*', cors()) // 允许跨域,方便前端开发
// 2. 挂载子路由
// 访问 /api/idols/* -> idolsApp
// 访问 /api/works/* -> worksApp
app.route('/idols', idolsApp)
app.route('/works', worksApp)
// 全局错误处理
app.onError((err, c) => {
console.error('API Error:', err)
return c.json({ error: err.message }, 500)
})
export const onRequest = handle(app)验证时刻
现在,你的后端已经具备了真实的业务能力。我们需要验证 API 是否工作正常。
启动后端 (如果之前没关,它会自动热更新;如果关了,重新运行):
bashnpm run backend测试创建女优 (POST)
打开一个新的终端窗口,使用
curl发送一个 POST 请求(或者使用 Postman):bashcurl -X POST http://127.0.0.1:8788/api/idols \ -H "Content-Type: application/json" \ -d '{ "name": "三上悠亚", "aliases": ["Yua Mikami", "鬼头桃菜"], "cup": "F", "bwh": {"bust": 88, "waist": 58, "hips": 89}, "birthday": "1993-08-16" }'
预期返回:{"success":true,"id":1}
测试查询列表 (GET)
bashcurl http://127.0.0.1:8788/api/idols
预期返回:你应该能看到刚刚创建的数据,且 aliases 已经被解析为数组,bwh 被解析为对象。
如果这两个接口都能通,说明我们的 Step 3: 后端模块化开发 已经圆满成功!我们现在拥有了一个可扩展、类型安全、且功能完备的后端核心。
对象存储与图片服务
目标:实现图片的上传、存储与高性能读取。
- 开发
upload.ts路由,处理 Multipart 上传。 - 开发图片代理/流式传输接口。
- 处理 MIME 类型和 HTTP 缓存头。
- 产出:图片上传接口,支持前端显示图片。
这一步的目标是打通 Cloudflare R2 对象存储。我们将实现一个“文件上传接口”和一个“图片读取接口”。
这样,你在创建女优或作品时,就可以先上传图片 -> 拿到 URL -> 再把 URL 存入数据库。
创建图片上传模块
创建上传模块 (:functions/routes/upload.ts)
我们需要处理前端发来的 multipart/form-data(表单文件上传)。Hono 提供了很好的解析支持。
新建文件 functions/routes/upload.ts:
import { Hono } from 'hono'
import { Bindings } from '../types'
const app = new Hono<{ Bindings: Bindings }>()
/** 上传图片 (PUT /api/upload) */
// 前端使用 FormData 上传,字段名为 'file'
app.put('/', async (c) => {
// 1. 解析表单数据
const body = await c.req.parseBody()
const file = body['file'] // 获取名为 file 的字段
// 2. 检查是否为文件
if (!file || !(file instanceof File)) {
return c.json({ error: 'No file uploaded' }, 400)
}
// 3. 生成唯一文件名 (时间戳 + 随机数 + 后缀)
// 也可以直接用 file.name,但为了防止重名覆盖,建议重命名
const suffix = file.name.split('.').pop() || 'jpg'
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${suffix}`
// 4. 写入 R2 存储桶
await c.env.R2.put(filename, await file.arrayBuffer(), {
httpMetadata: {
contentType: file.type // 保存文件的 MIME 类型 (如 image/png)
}
})
// 5. 返回相对路径,前端拼接显示
return c.json({
success: true,
url: `/api/images/${filename}`, // 这里的路径对应下面的读取接口
filename: filename
})
})
export default app修改 functions/api/[[route]].ts,挂载上传路由
// 1. 引入 upload 模块
import uploadApp from '../routes/upload' // <--- 新增引入
// ... 全局中间件 ...
// 2. 挂载上传路由
app.route('/upload', uploadApp) // <--- 新增挂载进阶:图片批量上传
这是一个非常实用的需求。在 jav-fs 项目中,实现批量上传主要涉及到两个改动:
后端 (Hono): 需要能够接收并处理
FormData中的文件数组。前端 (Vue): 需要使用
<input type="file" multiple>并正确封装请求。
我们需要修改 Step 4 中创建的 upload.ts。
修改后端接口
修改后端接口 (:functions/routes/upload.ts)
我们将接口逻辑升级:既支持单文件,也支持多文件。
核心逻辑变动:
- Hono 的
c.req.parseBody({ all: true }):我们需要加上{ all: true }选项,这样当上传多个文件时,它会返回一个数组,而不是只返回最后一个文件。 - 使用
Promise.all并行上传到 R2,提高速度。
请更新 functions/routes/upload.ts 的代码:
import { Hono } from 'hono'
import { Bindings } from '../types'
const app = new Hono<{ Bindings: Bindings }>()
// 修改为 POST 方法更符合语义 (批量创建资源)
app.post('/', async (c) => {
// 1. 解析 FormData,启用 { all: true } 以支持多文件数组
const body = await c.req.parseBody({ all: true })
// 2. 获取文件字段 (假设前端传的字段名为 'files')
// Hono 处理 parseBody 时,如果是单文件返回 File,多文件返回 File[]
// 我们统一转为数组处理
let files = body['files']
if (!files) {
return c.json({ error: 'No files uploaded' }, 400)
}
// 归一化:强制转为数组
if (!Array.isArray(files)) {
files = [files]
}
// 过滤掉非文件类型 (比如可能混入的文本字段)
const validFiles = (files as File[]).filter((f) => f instanceof File)
if (validFiles.length === 0) {
return c.json({ error: 'Invalid files' }, 400)
}
// 3. 并行上传到 R2
const uploadPromises = validFiles.map(async (file) => {
const suffix = file.name.split('.').pop() || 'jpg'
// 生成唯一文件名
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${suffix}`
await c.env.R2.put(filename, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type }
})
return {
originalName: file.name,
filename: filename,
url: `/api/images/${filename}`
}
})
// 等待所有上传完成
const results = await Promise.all(uploadPromises)
return c.json({
success: true,
count: results.length,
files: results
})
})
export default app注意:记得在 functions/api/[[route]].ts 中确认挂载路径。如果你之前写的是 app.put,现在要改用 POST 请求这个接口。
前端如何调用 (预览)
前端如何调用 (预览):
虽然我们还没到 Step 5,但我先给你看下前端代码怎么写,方便你理解原理。
在 Vue 组件中:
// 假设这是 handleUpload 方法
const uploadImages = async (event: Event) => {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
const formData = new FormData()
// 关键点:遍历所有选中的文件,append 到同一个字段名 'files' 下
for (let i = 0; i < input.files.length; i++) {
// 注意:这里的 key 必须和后端接收的 key ('files') 一致
formData.append('files', input.files[i])
}
const res = await fetch('/api/upload', {
method: 'POST', // 改为 POST
body: formData // 浏览器会自动设置 Content-Type 为 multipart/form-data
})
const data = await res.json()
console.log('上传成功:', data.files)
// data.files 是一个包含所有图片 URL 的数组
}HTML 部分:
<input type="file" multiple @change="uploadImages" />使用 Curl 测试批量上传
使用 Curl 测试批量上传:
你可以用 curl 模拟一次发两张图:
准备两个文件
a.jpg和b.jpg。运行命令:
bash# 注意 -F "files=@..." 写了两次,这相当于追加同名字段 curl -X POST \ -F "files=@a.jpg" \ -F "files=@b.jpg" \ http://127.0.0.1:8788/api/upload
预期返回结果:
{
"success": true,
"count": 2,
"files": [
{ "originalName": "a.jpg", "url": "/api/images/xxx-1.jpg", ... },
{ "originalName": "b.jpg", "url": "/api/images/xxx-2.jpg", ... }
]
}这样你就完美实现了批量上传功能!后端一次处理,并行写入 R2,效率很高。
创建图片读取接口
创建图片读取接口:
虽然 R2 可以配置自定义域名直接访问(性能最好),但为了开发方便(不需要买域名),我们可以直接用 Cloudflare Functions 做一个简单的代理。
我们需要在主入口文件 functions/api/[[route]].ts 中增加一个读取路由。
修改 functions/api/[[route]].ts,在 app.route 下方添加:
// ... 前面的 imports 和 app 初始化 ...
// [新增] 图片读取代理接口
// GET /api/images/:filename
app.get('/images/:filename', async (c) => {
const filename = c.req.param('filename')
// 1. 从 R2 获取对象
const object = await c.env.R2.get(filename)
if (!object) {
return c.text('Image Not Found', 404)
}
// 2. 设置响应头
const headers = new Headers()
object.writeHttpMetadata(headers)
// ETag 用于浏览器缓存
headers.set('etag', object.httpEtag)
// 设置缓存策略 (例如缓存 1 年)
headers.set('Cache-Control', 'public, max-age=31536000')
// 3. 返回图片
return new Response(object.body, {
headers
})
})
// ... 错误处理和导出 ...为什么这样做:
PUT /api/upload: 负责接收文件流,写入 R2。我们使用了parseBody()来自动处理 FormData,这样前端代码写起来最自然。GET /api/images/:filename: 这是一个“代理”。浏览器请求 API -> API 去 R2 取货 -> API 返回给浏览器。- 优点:无需配置 DNS,开箱即用,支持鉴权(如果需要)。
- 缺点:消耗 Functions 调用次数(但在免费额度内基本够用)。
验证时刻
我们需要验证 R2 是否工作正常。确保你的后端终端 (npm run backend) 正在运行。
准备一张测试图片:
找一张小图片,命名为 test.jpg,放在项目根目录下。
使用 Curl 测试上传:
打开新终端,在项目根目录执行:
# 使用 -F 模拟表单文件上传
curl -X PUT -F "file=@test.jpg" http://127.0.0.1:8788/api/upload预期输出:
{
"success": true,
"url": "/api/images/1707746123456-x7z9.jpg",
"filename": "1707746123456-x7z9.jpg"
}记下这个 url。
验证图片读取:
打开浏览器,访问:
http://127.0.0.1:8788 + 刚刚返回的 url
(例如:http://127.0.0.1:8788/api/images/1707746123456-x7z9.jpg)
如果你能在浏览器里看到这张图片,恭喜!🎉
前端 Vue 架构与页面开发
目标:对接后端 API,构建用户界面。
- 配置 Vite Proxy 解决本地开发跨域问题。
- 封装
useApi或fetch请求库。 - 开发“女优列表页” (Grid 布局 + 分页)。
- 开发“上传/新建页”。
- 产出:完整的前端交互界面。
太棒了,进入 Step 5: 前端 Vue 架构与页面开发!🎨
这一步我们将离开终端和黑底白字的 API,开始构建用户真正看得见、摸得着的界面。我们将实现一个现代化的 Single Page Application (SPA)。
目标:
搭建 Vue Router 路由系统。
封装 API 请求层。
实现 “女优列表页” (瀑布流展示)。
实现 “新建女优页” (包含我们刚刚讨论的批量图片上传功能)。
首先,确保你位于项目根目录 jav-fs/。
安装 Vue Router
安装 Vue Router:
我们需要路由来管理“列表页”和“新建页”之间的跳转。
npm install vue-router@4封装 API 请求层
封装 API 请求层 (:src/api/index.ts)
不要在每个组件里写 fetch('/api/...'),这很难维护。我们要统一封装。
创建 src/api 目录和 index.ts 文件:
mkdir -p src/api
touch src/api/index.ts写入以下代码:
// src/api/index.ts
import type { Idol } from '../../functions/types' // 复用后端的类型定义!
const API_PREFIX = '/api'
// 通用 Fetch 包装器
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_PREFIX}${url}`, options)
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Network response was not ok')
}
return res.json()
}
export const api = {
// 1. 获取女优列表
getIdols: (page = 1, search = '') =>
request<{ data: Idol[]; page: number; hasMore: boolean }>(`/idols?page=${page}&search=${search}`),
// 2. 创建女优
createIdol: (data: Partial<Idol>) =>
request<{ success: boolean; id: number }>('/idols', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}),
// 3. 上传图片 (支持批量)
uploadImages: async (files: FileList | File[]) => {
const formData = new FormData()
// 强制转为数组并遍历
Array.from(files).forEach((file) => formData.append('files', file))
// 注意:fetch 会自动设置 Content-Type 为 multipart/form-data,不要手动设置
return request<{ success: true; files: { url: string }[] }>('/upload', {
method: 'POST',
body: formData
})
}
}配置路由
配置路由 (:src/router/index.ts)
创建 src/router/index.ts:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import IdolList from '../views/IdolList.vue'
import IdolCreate from '../views/IdolCreate.vue'
const routes = [
{ path: '/', redirect: '/idols' },
{ path: '/idols', component: IdolList },
{ path: '/idols/new', component: IdolCreate }
]
export const router = createRouter({
history: createWebHistory(),
routes
})同时,修改 src/main.ts 启用路由:
// src/main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')开发页面组件
开发页面组件:
我们需要创建 src/views 文件夹。
1. 女优列表页 (src/views/IdolList.vue)
这是一个简单的网格布局,展示头像和名字。
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import type { Idol } from '../../functions/types'
const idols = ref<Idol[]>([])
const loading = ref(false)
const loadData = async () => {
loading.value = true
try {
const res = await api.getIdols()
idols.value = res.data
} finally {
loading.value = false
}
}
onMounted(loadData)
</script>
<template>
<div class="page">
<header>
<h1>女优列表</h1>
<router-link to="/idols/new" class="btn">➕ 新建女优</router-link>
</header>
<div v-if="loading">加载中...</div>
<div class="grid">
<div v-for="idol in idols" :key="idol.id" class="card">
<div class="avatar">
<img v-if="idol.avatar_url" :src="idol.avatar_url" loading="lazy" />
<div v-else class="placeholder">无图</div>
</div>
<h3>{{ idol.name }}</h3>
<p class="meta">{{ idol.cup }}杯 | {{ idol.age ? idol.age + '岁' : '未知' }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
.card {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
padding-bottom: 10px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.avatar {
height: 240px;
background: #f9f9f9;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
line-height: 240px;
color: #ccc;
}
.btn {
background: #3b82f6;
color: white;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
}
.meta {
color: #666;
font-size: 0.9em;
}
</style>2. 新建女优页 (src/views/IdolCreate.vue)
这里集成了图片上传逻辑。
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
const router = useRouter()
const form = reactive({
name: '',
cup: '',
aliases: '', // 输入时用逗号分隔
avatar_url: ''
})
const uploading = ref(false)
// 处理图片上传
const handleFileChange = async (e: Event) => {
const files = (e.target as HTMLInputElement).files
if (!files?.length) return
uploading.value = true
try {
// 调用我们写的批量上传接口,但这里只取第一张作为头像
const res = await api.uploadImages(files)
if (res.files.length > 0) {
form.avatar_url = res.files[0].url
}
} catch (err) {
alert('上传失败')
} finally {
uploading.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!form.name) return alert('请填写名字')
await api.createIdol({
name: form.name,
cup: form.cup,
// 将字符串 "A, B" 转为数组 ["A", "B"]
aliases: form.aliases
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean),
avatar_url: form.avatar_url
})
router.push('/idols') // 跳转回列表
}
</script>
<template>
<div class="page">
<h1>新建女优</h1>
<form @submit.prevent="handleSubmit" class="form">
<div class="form-group">
<label>头像</label>
<div class="upload-preview" v-if="form.avatar_url">
<img :src="form.avatar_url" />
</div>
<input type="file" @change="handleFileChange" accept="image/*" />
<span v-if="uploading">上传中...</span>
</div>
<div class="form-group">
<label>姓名</label>
<input v-model="form.name" type="text" placeholder="例如: 三上悠亚" />
</div>
<div class="form-group">
<label>别名 (用逗号分隔)</label>
<input v-model="form.aliases" type="text" placeholder="Yua Mikami, 鬼头" />
</div>
<div class="form-group">
<label>罩杯</label>
<select v-model="form.cup">
<option value="">请选择</option>
<option v-for="c in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']" :key="c" :value="c">
{{ c }}
</option>
</select>
</div>
<button type="submit" class="btn-primary">保存数据</button>
</form>
</div>
</template>
<style scoped>
.page {
max-width: 600px;
margin: 40px auto;
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.upload-preview img {
height: 150px;
border-radius: 4px;
margin-bottom: 10px;
}
.btn-primary {
background: #10b981;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
</style>修改 App 入口
修改 App 入口 (:src/App.vue)
最后,清理 App.vue,只保留路由出口。
<script setup lang="ts">
// 什么都不需要写
</script>
<template>
<router-view></router-view>
</template>
<style>
/* 全局重置样式 */
body {
margin: 0;
font-family: sans-serif;
background: #f5f5f5;
}
* {
box-sizing: border-box;
}
</style>验证时刻 (全栈联调)
✅ 验证时刻 (全栈联调):
这一步最激动人心,因为我们要把所有东西连起来了!
启动后端 (如果还没启动):
bashnpm run backend启动前端 (新建终端窗口):
bashnpm run dev打开浏览器:
访问
http://localhost:5173- 你应该能看到: 一个空的列表页(如果你之前清空了数据)。
- 点击 "➕ 新建女优": 跳转到表单页。
- 上传一张图片: 你会看到上传进度,然后图片预览显示出来(图片已经存入 R2)。
- 填写名字并保存: 页面自动跳转回列表页,并且显示出你刚刚创建的卡片!
🎉 恭喜!你已经完成了一个真正的全栈应用 (JAV-FS)。
- 前端:Vue 3 + Vue Router
- API:Hono (RESTful)
- 数据库:D1 (SQLite)
- 存储:R2 (Images)
- 环境:Cloudflare Pages
安全、优化与部署
目标:生产环境准备与上线。
- 配置 CORS 策略。
- 添加 API 鉴权中间件 (Bearer Token)。
- 前端构建与 Wrangler 部署流程。
- 产出:项目正式上线,可被公网访问。
太棒了!我们终于来到了最激动人心的最后一步!🚀
Step 6: 安全、优化与上线部署
你在本地已经拥有了一个完美运行的全栈应用。但如果要把它放到公网上,任何人都能随便调用你的 API 删改数据可不行。
这一步的目标是:
加把锁:为增删改接口(POST/PUT/PATCH)添加鉴权机制。
建线上资源:在 Cloudflare 真实环境中创建 D1 和 R2。
一键发布:将前后端打包,部署到全球边缘网络。
后端添加 API 鉴权
后端添加 API 鉴权 (Security):
我们需要在主路由拦截非法的写入请求。
修改 functions/api/[[route]].ts,在挂载子路由之前,添加一个简单的 Bearer Token 鉴权中间件:
// functions/api/[[route]].ts
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'
import { cors } from 'hono/cors'
import { Bindings } from '../types'
import idolsApp from '../routes/idols'
import worksApp from '../routes/works'
import uploadApp from '../routes/upload'
const app = new Hono<{ Bindings: Bindings }>().basePath('/api')
// 1. 允许跨域
app.use('/*', cors())
// 2. [新增] 简易鉴权中间件
app.use('/*', async (c, next) => {
// 允许所有 GET 请求 (公开读取)
if (c.req.method === 'GET') {
return await next()
}
// 拦截 POST, PUT, PATCH, DELETE 等写入请求
const authHeader = c.req.header('Authorization')
// 获取环境变量中的密钥,本地开发时默认用 'dev-secret'
const secret = c.env.API_SECRET || 'dev-secret'
if (authHeader !== `Bearer ${secret}`) {
return c.json({ error: 'Unauthorized: 密钥无效' }, 401)
}
await next()
})
// 3. 挂载子路由
app.route('/idols', idolsApp)
app.route('/works', worksApp)
app.route('/upload', uploadApp)
// 4. 图片读取代理
app.get('/images/:filename', async (c) => {
// ... 之前的图片代理代码保持不变 ...
const filename = c.req.param('filename')
const object = await c.env.R2.get(filename)
if (!object) return c.text('Not Found', 404)
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('etag', object.httpEtag)
return new Response(object.body, { headers })
})
export const onRequest = handle(app)前端携带 Token 发起请求
前端携带 Token 发起请求:
既然加入了鉴权,前端 POST 数据时必须带上钥匙。
修改 src/api/index.ts 中的 request 函数:
// src/api/index.ts
// ... 之前的导入保持不变 ...
// 模拟获取 Token,实际项目中可以做个登录页存到 localStorage
const getAuthToken = () => {
return localStorage.getItem('jav_token') || 'dev-secret'
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers || {})
// 注入 Authorization 头
headers.set('Authorization', `Bearer ${getAuthToken()}`)
const res = await fetch(`${API_PREFIX}${url}`, {
...options,
headers
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Network response was not ok')
}
return res.json()
}
// ... 下面的 api 对象保持不变 ...(提示:你可以在浏览器的控制台执行 localStorage.setItem('jav_token', '你的线上真实密码') 来模拟登录。)
在 Cloudflare 创建真实资源
在 Cloudflare 创建真实资源:
之前的开发都在本地模拟(.wrangler 文件夹),现在我们需要在 Cloudflare 云端创建真实的数据库和存储桶。
确保你已经通过 npx wrangler login 登录了你的 Cloudflare 账号。
1. 创建云端 D1 数据库
npx wrangler d1 create jav-db执行后,终端会打印出一段配置信息,类似这样:
[[d1_databases]]
binding = "DB"
database_name = "jav-db"
database_id = "xxxxxx-xxxx-xxxx-xxxx-xxxxxxxx" # 复制这个真实的 ID重要:将这段真实的 database_id 替换到你项目根目录的 wrangler.toml 文件中!
2. 初始化云端数据库表结构
我们需要把本地的表结构推送到刚刚创建的云端数据库。
npx wrangler d1 execute jav-db --remote --file=./schema.sql3. 创建云端 R2 存储桶
npx wrangler r2 bucket create jav-assets一键发布上线
一键发布上线 (Deployment):
万里长征最后一步!我们将 Vue 前端打包为静态文件,连同后端 API 一起部署到 Cloudflare Pages。
1. 打包前端代码
npm run build(这会生成一个 dist 文件夹,里面是压缩后的 HTML/JS/CSS)
2. 部署到 Cloudflare Pages
npx wrangler pages deploy dist部署过程中,CLI 会问你几个问题:
- Create a new project?: 选择
Create a new project(如果是第一次部署)。 - Enter production branch name: 直接按回车默认
main或master即可。
稍等片刻,Wrangler 会给你返回一个线上的 URL,类似:
https://jav-fs-xxx.pages.dev
设置线上环境变量
设置线上环境变量 (API_SECRET):
你的项目已经上线了!但为了安全,我们需要把云端的 API_SECRET 改成一个强密码,而不是 dev-secret。
登录 Cloudflare 控制台。
进入
Workers & Pages-> 找到你的项目jav-fs。去到 Settings (设置) -> Environment variables (环境变量)。
添加一个变量:
- Variable name:
API_SECRET - Value:
你自己设置的复杂密码(保存好这个密码,然后在你本地浏览器的 localStorage 里设置jav_token为这个密码)。
- Variable name:
保存后,重新执行一次部署命令 (
npx wrangler pages deploy dist) 使环境变量生效。
大功告成!
🎉 大功告成!:
恭喜你!你已经从零开始,完整构建并上线了一个现代化的全栈系统。回顾一下你学到了什么:
- 数据库设计:D1 (SQLite) 的 JSON 虚拟列优化与多对多关联设计。
- 后端开发:使用 Hono 框架构建轻量级、模块化的 RESTful API。
- 对象存储:结合 R2 处理
multipart/form-data实现图片上传。 - 前端开发:使用 Vue 3 + Composition API + Router 构建 SPA。
- 全栈部署:利用 Cloudflare 边缘网络,实现零运维、低成本的高性能部署。
这个项目 (jav-fs) 的架构非常扎实,完全可以作为你未来开发博客、商城、管理后台的通用模板。
如果你在部署过程中遇到任何报错,或者未来想要增加新功能(比如接入爬虫自动抓取数据),随时可以来找我讨论!需要我帮你整理一份完整的 README 吗?